# import logging
from os import PathLike
from pathlib import Path
from tempfile import TemporaryDirectory
from time import time_ns
from typing import Optional, TypedDict, overload, ParamSpec, TypeVar

import arcpy
import pandas as pd
# noinspection PyUnresolvedReferences
from arcgis.features import GeoAccessor, GeoSeriesAccessor

from .config_dataclasses import NG911FeatureClass
from .session import config
from .gdbsession import NG911Session

# _logger = logging.getLogger(__name__)
# _logger.info(__name__)

_P = ParamSpec("_P")
_T = TypeVar("_T")


class _GDBDiffRecordDict(TypedDict):
    Identifier: str
    Message: str
    Base_value: str | int | float | bool
    Test_value: str | int | float | bool


class FeatureClassNotFoundError(OSError):
    def __init__(self, session: NG911Session, feature_class: NG911FeatureClass):
        self.session = session
        self.feature_class = feature_class
        super().__init__(f"{self.path} does not exist.")

    @property
    def path(self) -> Path:
        return self.session.path_to(self.feature_class)


class NG911GeodatabaseComparator:
    gdb1: NG911Session
    gdb2: NG911Session

    @overload
    def __init__(self, gdb1: NG911Session, gdb2: NG911Session): ...

    @overload
    def __init__(self, gdb1: PathLike[str] | str, gdb2: PathLike[str] | str, respect_submit: bool): ...

    def __init__(self, gdb1, gdb2, respect_submit=None):
        gdb1_is_session = isinstance(gdb1, NG911Session)
        gdb2_is_session = isinstance(gdb2, NG911Session)
        if respect_submit is not None and (
                (gdb1_is_session and gdb1.respect_submit != respect_submit)
                or (gdb2_is_session and gdb2.respect_submit != respect_submit)):
            raise ValueError(f"If respect_submit is provided, it must match the respect_submit attribute of any NG911Session objects passed as arguments.")
        if gdb1_is_session and gdb2_is_session and gdb1.respect_submit != gdb2.respect_submit:
            raise ValueError(f"gdb1.respect_submit = {gdb1.respect_submit}, but gdb2.respect_submit = {gdb2.respect_submit}.")
        if not (gdb1_is_session and gdb2_is_session) and respect_submit is None:
            raise TypeError(f"An argument for respect_submit is required unless the arguments for both GDB parameters are instances of NG911Session.")

        self.gdb1 = gdb1 if isinstance(gdb1, NG911Session) else NG911Session(gdb1, respect_submit)
        self.gdb2 = gdb2 if isinstance(gdb2, NG911Session) else NG911Session(gdb2, respect_submit)

    @overload
    def compare_feature_class(self, feature_class: NG911FeatureClass, output_location: Path, output_name: str) -> tuple[pd.DataFrame, Path]: ...

    @overload
    def compare_feature_class(self, feature_class: NG911FeatureClass) -> pd.DataFrame: ...

    def compare_feature_class(self, feature_class: NG911FeatureClass, output_location: Optional[Path] = None, output_name: Optional[str] = None) -> tuple[pd.DataFrame, Path] | pd.DataFrame:
        # Get paths to feature classes
        fc1 = str(self.gdb1.path_to(feature_class))
        fc2 = str(self.gdb2.path_to(feature_class))

        # Ensure both feature classes exist
        if not arcpy.Exists(fc1):
            raise FeatureClassNotFoundError(self.gdb1, feature_class)
        if not arcpy.Exists(fc2):
            raise FeatureClassNotFoundError(self.gdb2, feature_class)

        # If output is requested and the destination is NOT a workspace/GDB, run comparison and write directly to CSV
        if output_location and output_name and arcpy.Describe(str(output_location)).dataType != "Workspace":
            output_path = output_location / output_name
            return self._get_comparison_df(feature_class, fc1, fc2, output_path), output_path

        # Otherwise, run the comparison and write the output to a temporary directory
        # If output_location and output_name are provided, then write to a GDB table
        else:
            with TemporaryDirectory() as output_dir:
                temp_output_path = Path(output_dir) / "compare_result.csv"
                df = self._get_comparison_df(feature_class, fc1, fc2, temp_output_path)

            if output_location and output_name:
                # At this point, output_location must be a workspace/GDB
                assert isinstance(df.spatial, GeoAccessor)
                output_path = output_location / output_name
                df.spatial.to_table(str(output_location / output_name), sanitize_columns=False)
                return df, output_path
            else:
                return df

    def compare_geodatabases(self, export: bool = False) -> pd.DataFrame:
        """
        Compares the NG911 feature classes of the comparator's two
        geodatabases. Optionally exports a table of differences (named
        "ComparisonResults") to the first geodatabase.

        Columns in the output data frame (and optional table) include:

        * ``Identifier`` - The type of difference
        * ``Message`` - Prose description of the difference
        * ``Base_value`` - The value in GDB #1
        * ``Test_value`` - The value in GDB #2
        * ``Layer`` - The name of the feature class containing the difference
        * ``Field`` - The name of the field containing the difference

        :param export: Whether to export a table of differences to the first
            geodatabase, default False
        :type export: bool
        :return: Tabular representation (data frame) of the differences
        :rtype: pandas.DataFrame
        """
        dfs: list[pd.DataFrame] = []
        """List to store data frames resulting from each per-feature-class comparison."""

        gdb_diff_records: list[_GDBDiffRecordDict] = []
        """List to store records indicating a feature class is missing entirely
        from one of the geodatabases; these records will be included in the
        final data frame. This is necessary because no record of the difference
        would otherwise be included in the output table."""

        # Iterate over all NG911 feature classes
        for fc in config.feature_classes.values():
            # Get paths and check existence
            fc1 = str(self.gdb1.path_to(fc))
            fc2 = str(self.gdb2.path_to(fc))
            fc1_exists = arcpy.Exists(fc1)
            fc2_exists = arcpy.Exists(fc2)

            if all([fc1_exists, fc2_exists]):
                # They both exist; run comparison
                dfs.append(self.compare_feature_class(fc))
            elif not any([fc1_exists, fc2_exists]):
                # Neither exist; print message and move on
                self.gdb1.messenger.addMessage(f"NOTICE: Neither {fc1} nor {fc2} exist.")
            else:
                # One exists, the other doesn't; print message and add GDB diff record
                existing = {fc1_exists: fc1, fc2_exists: fc2}[True]
                missing = {fc1_exists: fc1, fc2_exists: fc2}[False]
                self.gdb1.messenger.addMessage(f"NOTICE: {existing} exists, but {missing} does not.")
                gdb_diff_records.append({
                    "Identifier": "Feature Class Existence",
                    "Message": f"{existing} exists, but {missing} does not.",
                    "Base_value": "<Exists>" if fc1_exists else "<Missing>",
                    "Test_value": "<Exists>" if fc2_exists else "<Missing>"
                })

        # Concatenate all the result data frames (including one made from gdb_diff_records)
        df: pd.DataFrame = pd.concat([pd.DataFrame.from_records(gdb_diff_records), *dfs], ignore_index=True)
        if export:
            df.spatial.to_table(str(self.gdb1.gdb_path / "ComparisonResults"), sanitize_columns=False)
        return df

    @staticmethod
    def _get_comparison_df(feature_class: NG911FeatureClass, fc1: str, fc2: str, csv_destination: Path) -> pd.DataFrame:
        arcpy.management.FeatureCompare(
            in_base_features=fc1,
            in_test_features=fc2,
            sort_field=feature_class.unique_id.name,
            compare_type="ALL",
            omit_field=["OBJECTID"],
            continue_compare=True,
            out_compare_file=str(csv_destination)
        )
        table_name = fr"ng911_comparison_{time_ns()}"  # Generate presumably-unique name for temporary table
        arcpy.conversion.TableToTable(str(csv_destination), "memory", table_name)
        try:
            _df: pd.DataFrame = pd.DataFrame.spatial.from_table(
                fr"memory\{table_name}",
                fields=["Has_error", "Identifier", "Message", "Base_value", "Test_value", "ObjectID"],
                skip_nulls=False
            )
        finally:
            # If creating _df were to fail, this would still delete the temporary table before raising the exception
            arcpy.management.Delete(fr"memory\{table_name}")
        _df = _df[_df["Has_error"] == "true"].drop(columns="Has_error")
        _df["Layer"] = feature_class.name
        _df["Field"] = _df["Message"].str.extract(r"is different for Field (\w+)")  # Grabs the field name, if present, from the "Message" column
        return _df

    # @staticmethod
    # def _is_number_so_far(token: str):
    #     return (x := token.replace(".", "", 1)).isdecimal() or not x
    #
    # @staticmethod
    # def _cast_numeric_token(token: str):
    #     if not token.replace(".", "", 1).isdecimal():
    #         raise ValueError(f"Token '{token}' is not numeric.")
    #     elif "." in token:
    #         return float(token)
    #     else:
    #         return int(token)
    #
    # @staticmethod
    # def _tokenize_header(text: str) -> Iterator[str]:
    #     for token in text.split(", "):
    #         if not re.match(r"^[A-Za-z]\w*$", token):
    #             raise ValueError(f"Invalid column name: '{token}'")
    #         else:
    #             yield token
    #
    # def _tokenize_line(self, text: str) -> Iterator[str | int | float]:
    #     inside_quotes: bool = False
    #     escaping: bool = False
    #     token: str = ""
    #     last_pos: int = len(text) - 1
    #     is_last_char: bool
    #     pos: int
    #     char: str
    #     for pos, char in enumerate(text):
    #         is_last_char = pos == last_pos
    #         print(f"{token} | {char}", end="")
    #         print("[Last character]" if is_last_char else "")
    #         if escaping:
    #             token += char
    #             escaping = False
    #             continue
    #         elif char == "\\":
    #             escaping = True
    #             continue
    #         elif char == '"' and not escaping:
    #             inside_quotes = not inside_quotes
    #             if not inside_quotes:
    #                 yield token
    #                 token = ""
    #             continue
    #         elif inside_quotes:
    #             token += char
    #             continue
    #         elif not inside_quotes and token == "" and char == ",":
    #             token += char
    #             continue
    #         elif not inside_quotes and token == "," and char == " ":
    #             token = ""
    #             continue
    #         elif not inside_quotes and (token == "" or self._is_number_so_far(token)):
    #             if char == ",":
    #                 yield self._cast_numeric_token(token)
    #                 token = ","
    #                 continue
    #             elif is_last_char:
    #                 token += char
    #                 yield self._cast_numeric_token(token)
    #                 continue
    #             elif char.isdecimal() or (char == "." and "." not in token):
    #                 token += char
    #                 continue
    #
    #         raise ValueError(f"Position: {pos}; Character: '{char}'; Current Token: '{token}'; Inside Quotes: {inside_quotes}; Escaping: {escaping}; Is Last Character: {is_last_char}")
